哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached、redis)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一般。本文将深入源码,分析HashMap实现原理。源码基于JDk1.8。
必备知识
HashMap的底层原理基于哈希表,如果你还未了解哈希表原理,请参考《浅谈算法和数据结构: 十一 哈希表》。
常量
1 | /** |
- DEFAULT_INITIAL_CAPACITY: 默认初始化容量为16,必须是2的幂;
- MAXIMUM_CAPACITY: 最大容量;
- DEFAULT_LOAD_FACTOR: 默认装载因子0.75;
- TREEIFY_THRESHOLD: 一个桶的树化阈值。当桶中元素个数超过这个值时,链表进化为红黑树;
- UNTREEIFY_THRESHOLD: 一个树的链表还原阈值。当扩容时,桶中元素个数小于这个值,就会把树形的桶元素还原为链表结构;
- MIN_TREEIFY_CAPACITY: 哈希表的最小树形化容量。当哈希表中的容量大于这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化。
成员变量
1 | /** |
- table: 存放KV数据的数组。第一次使用的时候被初始化,根据需要可以重新resize。分配的长度总是2的幂;
- entrySet: 当被调用entrySet时被赋值。通过keySet()方法可以得到map key的集合,通过values方法可以得到map value的集合;
- size: 存放在map中的KV映射的总数;
- modCount: HashMap被结构性修改的次数。(结构性修改是指改变了KV映射数量的操作或者修改了HashMap的内部结构(如 rehash)。这个用于fail-fast;
- threshold: 当需要resize时的阈值。即当HashMap中KV映射的数量(即size)超过了threshold就会resize。threshold=capacity*loadFactor;
- loadFactor: 装载因子。
注意:在成员变量中并没有capacity这个数据,当然capacity可以通过threshold和loadFactor计算得来。
内部类
Node
1 | /** |
TreeNode
1 | /** |
重要方法解析
构造方法
1 | /** |
hash()方法
1 | // 散列值优化函数 |
这里通过key.hashCode()计算出key的哈希值,然后将哈希值h右移16位,再与原来的h做异或^运算——这一步是高位运算。设想一下,如果没有高位运算,那么hash值将是一个int型的32位数。而从2的-31次幂到2的31次幂之间,有将近几十亿的空间,如果我们的HashMap的table有这么长,内存早就爆了。所以这个散列值不能直接用来最终的取模运算,而需要先加入高位运算,将高16位和低16位的信息”融合”到一起,也称为”扰动函数”。这样才能保证hash值所有位的数值特征都保存下来而没有遗漏,从而使映射结果尽可能的松散。最后,根据 n-1 做与操作的取模运算。这里也能看出为什么HashMap要限制table的长度为2的n次幂,因为这样,n-1可以保证二进制展示形式是(以16为例)0000 0000 0000 0000 0000 0000 0000 1111。在做”与”操作时,就等同于截取hash二进制值得后四位数据作为下标。这里也可以看出”扰动函数”的重要性了,如果高位不参与运算,那么高16位的hash特征几乎永远得不到展现,发生hash碰撞的几率就会增大,从而影响性能。
put()方法
1 | /** |
resize()方法
1 | /** |
get()方法
1 | /** |
其他细节
被transient所修饰table变量
如果大家细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?
这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:
- table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间;
- 同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。
以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。
综上所述,大家应该能明白HashMap不序列化table的原因了。
HashMap为什么线程不安全
一直以来只是知道HashMap是线程不安全的,但是到底HashMap为什么线程不安全,多线程并发的时候在什么情况下可能出现问题?
javadoc中关于hashmap的一段描述如下:
此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap() 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
1 | Map m = Collections.synchronizedMap(new HashMap(...)); |
- 如果多个线程同时使用put方法添加元素,而且假设正好存在两个put的key发生了碰撞(hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖;
- 如果多个线程同时检测到元素个数超过数组大小*loadFactor,这样就会发生多个线程同时对Node数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失;
- jdk 1.7中resize()导致死循环。
如何线程安全的使用HashMap
- Hashtable
- ConcurrentHashMap
- Synchronized Map
1 | //Hashtable |
1. Hashtable
HashTable源码中是使用synchronized来保证线程安全的,比如下面的get方法和put方法:
1 | public synchronized V get(Object key) { |
所以当一个线程访问HashTable的同步方法时,其他线程如果也要访问同步方法,会被阻塞住。举个例子,当一个线程使用put方法时,另一个线程不但不可以使用put方法,连get方法都不可以。所以,效率很低,现在基本不会选择它了。
2. ConcurrentHashMap
ConcurrentHashMap(以下简称CHM)是JUC包中的一个类。jdk 1.7和1.8有区别,在1.8中CHM摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用CAS算法。
3. SynchronizedMap
调用synchronizedMap()方法后会返回一个SynchronizedMap类的对象,而在SynchronizedMap类中使用了synchronized同步关键字来保证对Map的操作是线程安全的。
各JDK版本中HashMap的区别
- 确定哈希桶数组索引位置区别:JDK1.8优化了高位运算的算法,通过hashCode()的高16位异或低16位实现;
- 新节点插入位置的区别:JDK1.7新节点插入在链表头部,JDK1.8新节点插入在链表尾部;
- resize的区别:JDK1.7重新计算所有节点位置,一个个的插入新HashMap中,JDK1.8一个桶中的元素,只会在新桶或原桶;
- 哈希冲突解决方法区别:JDK1.8引入红黑树,当链表节点个数达到8将进行树化。